/**
 * @fileoverview Operator linebreak - enforces operator linebreak style of two types: after and before
 * @author Benoît Zugmeyer
 */

import type { ASTNode, ReportFixFunction, Token, Tree } from '#types'
import type { MessageIds, RuleOptions } from './types'
import { createGlobalLinebreakMatcher, isTokenOnSameLine } from '#utils/ast'
import { createRule } from '#utils/create-rule'

export default createRule<RuleOptions, MessageIds>({
  name: 'operator-linebreak',
  meta: {
    type: 'layout',

    docs: {
      description: 'Enforce consistent linebreak style for operators',
    },

    schema: [
      {
        oneOf: [
          {
            type: 'string',
            enum: ['after', 'before', 'none'],
          },
          {
            type: 'null',
          },
        ],
      },
      {
        type: 'object',
        properties: {
          overrides: {
            type: 'object',
            additionalProperties: {
              type: 'string',
              enum: ['after', 'before', 'none', 'ignore'],
            },
          },
        },
        additionalProperties: false,
      },
    ],

    fixable: 'code',

    messages: {
      operatorAtBeginning: '\'{{operator}}\' should be placed at the beginning of the line.',
      operatorAtEnd: '\'{{operator}}\' should be placed at the end of the line.',
      badLinebreak: 'Bad line breaking before and after \'{{operator}}\'.',
      noLinebreak: 'There should be no line break before or after \'{{operator}}\'.',
    },
  },

  create(context) {
    const usedDefaultGlobal = !context.options[0]
    const globalStyle = context.options[0] || 'after'
    const options = context.options[1] || {}
    const styleOverrides = options.overrides ? Object.assign({}, options.overrides) : {}

    if (usedDefaultGlobal && !styleOverrides['?'])
      styleOverrides['?'] = 'before'

    if (usedDefaultGlobal && !styleOverrides[':'])
      styleOverrides[':'] = 'before'

    const sourceCode = context.sourceCode

    /**
     * Gets a fixer function to fix rule issues
     * @param operatorToken The operator token of an expression
     * @param desiredStyle The style for the rule. One of 'before', 'after', 'none'
     * @returns A fixer function
     */
    function getFixer(operatorToken: Token, desiredStyle: string): ReportFixFunction {
      return (fixer) => {
        const tokenBefore = sourceCode.getTokenBefore(operatorToken)!
        const tokenAfter = sourceCode.getTokenAfter(operatorToken)!
        const textBefore = sourceCode.text.slice(tokenBefore.range[1], operatorToken.range[0])
        const textAfter = sourceCode.text.slice(operatorToken.range[1], tokenAfter.range[0])
        const hasLinebreakBefore = !isTokenOnSameLine(tokenBefore, operatorToken)
        const hasLinebreakAfter = !isTokenOnSameLine(operatorToken, tokenAfter)
        let newTextBefore, newTextAfter

        if (hasLinebreakBefore !== hasLinebreakAfter && desiredStyle !== 'none') {
          // If there is a comment before and after the operator, don't do a fix.
          if (
            sourceCode.getTokenBefore(operatorToken, { includeComments: true }) !== tokenBefore
            && sourceCode.getTokenAfter(operatorToken, { includeComments: true }) !== tokenAfter
          ) {
            return null
          }

          /**
           * If there is only one linebreak and it's on the wrong side of the operator, swap the text before and after the operator.
           * foo &&
           *           bar
           * would get fixed to
           * foo
           *        && bar
           */
          newTextBefore = textAfter
          newTextAfter = textBefore
        }
        else {
          const LINEBREAK_REGEX = createGlobalLinebreakMatcher()

          // Otherwise, if no linebreak is desired and no comments interfere, replace the linebreaks with empty strings.
          newTextBefore = desiredStyle === 'before' || textBefore.trim() ? textBefore : textBefore.replace(LINEBREAK_REGEX, '')
          newTextAfter = desiredStyle === 'after' || textAfter.trim() ? textAfter : textAfter.replace(LINEBREAK_REGEX, '')

          // If there was no change (due to interfering comments), don't output a fix.
          if (newTextBefore === textBefore && newTextAfter === textAfter)
            return null
        }

        if (newTextAfter === '' && tokenAfter.type === 'Punctuator' && '+-'.includes(operatorToken.value) && tokenAfter.value === operatorToken.value) {
          // To avoid accidentally creating a ++ or -- operator, insert a space if the operator is a +/- and the following token is a unary +/-.
          newTextAfter += ' '
        }

        return fixer.replaceTextRange([tokenBefore.range[1], tokenAfter.range[0]], newTextBefore + operatorToken.value + newTextAfter)
      }
    }

    /**
     * Checks the operator placement
     * @param node The node to check
     * @param rightSide The node that comes after the operator in `node`
     * @param operator The operator
     * @private
     */
    function validateNode(node: ASTNode, rightSide: ASTNode, operator: string) {
      /**
       * Find the operator token by searching from the right side, because between the left side and the operator
       * there could be additional tokens from type annotations. Search specifically for the token which
       * value equals the operator, in order to skip possible opening parentheses before the right side node.
       */
      const operatorToken = sourceCode.getTokenBefore(rightSide, token => token.value === operator)!
      const leftToken = sourceCode.getTokenBefore(operatorToken)!
      const rightToken = sourceCode.getTokenAfter(operatorToken)!
      const operatorStyleOverride = styleOverrides[operator]
      const style = operatorStyleOverride || globalStyle
      const fix = getFixer(operatorToken, style)

      // if single line
      if (isTokenOnSameLine(leftToken, operatorToken) && isTokenOnSameLine(operatorToken, rightToken)) {
        // do nothing.
      }
      else if (
        operatorStyleOverride !== 'ignore'
        && !isTokenOnSameLine(leftToken, operatorToken)
        && !isTokenOnSameLine(operatorToken, rightToken)
      ) {
        // lone operator
        context.report({
          node,
          loc: operatorToken.loc,
          messageId: 'badLinebreak',
          data: {
            operator,
          },
          fix,
        })
      }
      else if (style === 'before' && isTokenOnSameLine(leftToken, operatorToken)) {
        context.report({
          node,
          loc: operatorToken.loc,
          messageId: 'operatorAtBeginning',
          data: {
            operator,
          },
          fix,
        })
      }
      else if (style === 'after' && isTokenOnSameLine(operatorToken, rightToken)) {
        context.report({
          node,
          loc: operatorToken.loc,
          messageId: 'operatorAtEnd',
          data: {
            operator,
          },
          fix,
        })
      }
      else if (style === 'none') {
        context.report({
          node,
          loc: operatorToken.loc,
          messageId: 'noLinebreak',
          data: {
            operator,
          },
          fix,
        })
      }
    }

    /**
     * Validates a binary expression using `validateNode`
     * @param node node to be validated
     */
    function validateBinaryExpression(node: Tree.BinaryExpression | Tree.LogicalExpression | Tree.AssignmentExpression) {
      validateNode(node, node.right, node.operator)
    }

    return {
      BinaryExpression: validateBinaryExpression,
      LogicalExpression: validateBinaryExpression,
      AssignmentExpression: validateBinaryExpression,
      VariableDeclarator(node) {
        if (node.init)
          validateNode(node, node.init, '=')
      },
      PropertyDefinition(node) {
        if (node.value)
          validateNode(node, node.value, '=')
      },
      AccessorProperty(node) {
        if (node.value)
          validateNode(node, node.value, '=')
      },
      ConditionalExpression(node) {
        validateNode(node, node.consequent, '?')
        validateNode(node, node.alternate, ':')
      },
      TSImportEqualsDeclaration(node) {
        validateNode(node, node.moduleReference, '=')
      },
      TSTypeAliasDeclaration(node) {
        validateNode(node, node.typeAnnotation, '=')
      },
      TSConditionalType(node) {
        validateNode(node, node.trueType, '?')
        validateNode(node, node.falseType, ':')
      },
      TSIntersectionType(node) {
        const { types } = node
        for (let idx = 0; idx < types.length - 1; idx++) {
          validateNode(types[idx], types[idx + 1], '&')
        }
      },
      TSUnionType(node) {
        const { types } = node
        for (let idx = 0; idx < types.length - 1; idx++) {
          validateNode(types[idx], types[idx + 1], '|')
        }
      },
      TSTypeParameter(node) {
        if (!node.default)
          return
        validateNode(node, node.default, '=')
      },
      TSEnumMember(node) {
        if (!node.initializer)
          return
        validateNode(node, node.initializer, '=')
      },
    }
  },
})
